#!/bin/sh

# zfs-mount-generator - generates systemd mount units for zfs
# Copyright (c) 2017 Antonio Russo <antonio.e.russo@gmail.com>
# Copyright (c) 2020 InsanePrawn <insane.prawny@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

set -e

FSLIST="@sysconfdir@/zfs/zfs-list.cache"

[ -d "${FSLIST}" ] || exit 0

do_fail() {
  printf 'zfs-mount-generator: %s\n' "$*" > /dev/kmsg
  exit 1
}

# test if $1 is in space-separated list $2
is_known() {
  query="$1"
  IFS=' '
  for element in $2 ; do
    if [ "$query" = "$element" ] ; then
      return 0
    fi
  done
  return 1
}

# create dependency on unit file $1
# of type $2, i.e. "wants" or "requires"
# in the target units from space-separated list $3
create_dependencies() {
  unitfile="$1"
  suffix="$2"
  IFS=' '
  for target in $3 ; do
    target_dir="${dest_norm}/${target}.${suffix}/"
    mkdir -p "${target_dir}"
    ln -s "../${unitfile}" "${target_dir}"
  done
}

# see systemd.generator
if [ $# -eq 0 ] ; then
  dest_norm="/tmp"
elif [ $# -eq 3 ] ; then
  dest_norm="${1}"
else
  do_fail "zero or three arguments required"
fi

pools=$(zpool list -H -o name || true)

# All needed information about each ZFS is available from
# zfs list -H -t filesystem -o <properties>
# cached in $FSLIST, and each line is processed by the following function:
# See the list below for the properties and their order

process_line() {

  # zfs list -H -o name,...
  # fields are tab separated
  IFS="$(printf '\t')"
  # shellcheck disable=SC2086
  set -- $1

  dataset="${1}"
  pool="${dataset%%/*}"
  p_mountpoint="${2}"
  p_canmount="${3}"
  p_atime="${4}"
  p_relatime="${5}"
  p_devices="${6}"
  p_exec="${7}"
  p_readonly="${8}"
  p_setuid="${9}"
  p_nbmand="${10}"
  p_encroot="${11}"
  p_keyloc="${12}"
  p_systemd_requires="${13}"
  p_systemd_requiresmountsfor="${14}"
  p_systemd_before="${15}"
  p_systemd_after="${16}"
  p_systemd_wantedby="${17}"
  p_systemd_requiredby="${18}"
  p_systemd_nofail="${19}"
  p_systemd_ignore="${20}"

  # Minimal pre-requisites to mount a ZFS dataset
  # By ordering before zfs-mount.service, we avoid race conditions.
  after="zfs-import.target"
  before="zfs-mount.service"
  wants="zfs-import.target"
  requires=""
  requiredmounts=""
  bindsto=""
  wantedby=""
  requiredby=""
  noauto="off"

  # If the pool is already imported, zfs-import.target is not needed.  This
  # avoids a dependency loop on root-on-ZFS systems:
  # systemd-random-seed.service After (via RequiresMountsFor) var-lib.mount
  # After zfs-import.target After zfs-import-{cache,scan}.service After
  # cryptsetup.service After systemd-random-seed.service.
  #
  # Pools are newline-separated and may contain spaces in their names.
  # There is no better portable way to set IFS to just a newline.  Using
  # $(printf '\n') doesn't work because $(...) strips trailing newlines.
  IFS="
"
  for p in $pools ; do
    if [ "$p" = "$pool" ] ; then
      after=""
      wants=""
      break
    fi
  done

  if [ -n "${p_systemd_after}" ] && \
      [ "${p_systemd_after}" != "-" ] ; then
    after="${p_systemd_after} ${after}"
  fi

  if [ -n "${p_systemd_before}" ] && \
      [ "${p_systemd_before}" != "-" ] ; then
    before="${p_systemd_before} ${before}"
  fi

  if [ -n "${p_systemd_requires}" ] && \
      [ "${p_systemd_requires}" != "-" ] ; then
    requires="Requires=${p_systemd_requires}"
  fi

  if [ -n "${p_systemd_requiresmountsfor}" ] && \
      [ "${p_systemd_requiresmountsfor}" != "-" ] ; then
    requiredmounts="RequiresMountsFor=${p_systemd_requiresmountsfor}"
  fi

  # Handle encryption
  if [ -n "${p_encroot}" ] &&
      [ "${p_encroot}" != "-" ] ; then
    keyloadunit="zfs-load-key-$(systemd-escape "${p_encroot}").service"
    if [ "${p_encroot}" = "${dataset}" ] ; then
      keymountdep=""
      if [ "${p_keyloc%%://*}" = "file" ] ; then
        if [ -n "${requiredmounts}" ] ; then
          keymountdep="${requiredmounts} '${p_keyloc#file://}'"
        else
          keymountdep="RequiresMountsFor='${p_keyloc#file://}'"
        fi
        keyloadscript="@sbindir@/zfs load-key \"${dataset}\""
      elif [ "${p_keyloc}" = "prompt" ] ; then
        keyloadscript="\
count=0;\
while [ \$\$count -lt 3 ];do\
  systemd-ask-password --id=\"zfs:${dataset}\"\
    \"Enter passphrase for ${dataset}:\"|\
    @sbindir@/zfs load-key \"${dataset}\" && exit 0;\
  count=\$\$((count + 1));\
done;\
exit 1"
      else
        printf 'zfs-mount-generator: (%s) invalid keylocation\n' \
          "${dataset}" >/dev/kmsg
      fi
      keyloadcmd="\
/bin/sh -c '\
set -eu;\
keystatus=\"\$\$(@sbindir@/zfs get -H -o value keystatus \"${dataset}\")\";\
[ \"\$\$keystatus\" = \"unavailable\" ] || exit 0;\
${keyloadscript}'"
      keyunloadcmd="\
/bin/sh -c '\
set -eu;\
keystatus=\"\$\$(@sbindir@/zfs get -H -o value keystatus \"${dataset}\")\";\
[ \"\$\$keystatus\" = \"available\" ] || exit 0;\
@sbindir@/zfs unload-key \"${dataset}\"'"



      # Generate the key-load .service unit
      #
      # Note: It is tempting to use a `<<EOF` style here-document for this, but
      #   bash requires a writable /tmp or $TMPDIR for that. This is not always
      #   available early during boot.
      #
      echo \
"# Automatically generated by zfs-mount-generator

[Unit]
Description=Load ZFS key for ${dataset}
SourcePath=${cachefile}
Documentation=man:zfs-mount-generator(8)
DefaultDependencies=no
Wants=${wants}
After=${after}
${requires}
${keymountdep}

[Service]
Type=oneshot
RemainAfterExit=yes
# This avoids a dependency loop involving systemd-journald.socket if this
# dataset is a parent of the root filesystem.
StandardOutput=null
StandardError=null
ExecStart=${keyloadcmd}
ExecStop=${keyunloadcmd}"   > "${dest_norm}/${keyloadunit}"
    fi
    # Update the dependencies for the mount file to want the
    # key-loading unit.
    wants="${wants}"
    bindsto="BindsTo=${keyloadunit}"
    after="${after} ${keyloadunit}"
  fi

  # Prepare the .mount unit

  # skip generation of the mount unit if org.openzfs.systemd:ignore is "on"
  if [ -n "${p_systemd_ignore}" ] ; then
    if [ "${p_systemd_ignore}" = "on" ] ; then
      return
    elif [ "${p_systemd_ignore}" = "-" ] \
      || [ "${p_systemd_ignore}" = "off" ] ; then
      : # This is OK
    else
      do_fail "invalid org.openzfs.systemd:ignore for ${dataset}"
    fi
  fi

  # Check for canmount=off .
  if [ "${p_canmount}" = "off" ] ; then
    return
  elif [ "${p_canmount}" = "noauto" ] ; then
    noauto="on"
  elif [ "${p_canmount}" = "on" ] ; then
    : # This is OK
  else
    do_fail "invalid canmount for ${dataset}"
  fi

  # Check for legacy and blank mountpoints.
  if [ "${p_mountpoint}" = "legacy" ] ; then
    return
  elif [ "${p_mountpoint}" = "none" ] ; then
    return
  elif [ "${p_mountpoint%"${p_mountpoint#?}"}" != "/" ] ; then
    do_fail "invalid mountpoint for ${dataset}"
  fi

  # Escape the mountpoint per systemd policy.
  mountfile="$(systemd-escape --path --suffix=mount "${p_mountpoint}")"

  # Parse options
  # see lib/libzfs/libzfs_mount.c:zfs_add_options
  opts=""

  # atime
  if [ "${p_atime}" = on ] ; then
    # relatime
    if [ "${p_relatime}" = on ] ; then
      opts="${opts},atime,relatime"
    elif [ "${p_relatime}" = off ] ; then
      opts="${opts},atime,strictatime"
    else
      printf 'zfs-mount-generator: (%s) invalid relatime\n' \
        "${dataset}" >/dev/kmsg
    fi
  elif [ "${p_atime}" = off ] ; then
    opts="${opts},noatime"
  else
    printf 'zfs-mount-generator: (%s) invalid atime\n' \
      "${dataset}" >/dev/kmsg
  fi

  # devices
  if [ "${p_devices}" = on ] ; then
    opts="${opts},dev"
  elif [ "${p_devices}" = off ] ; then
    opts="${opts},nodev"
  else
    printf 'zfs-mount-generator: (%s) invalid devices\n' \
      "${dataset}" >/dev/kmsg
  fi

  # exec
  if [ "${p_exec}" = on ] ; then
    opts="${opts},exec"
  elif [ "${p_exec}" = off ] ; then
    opts="${opts},noexec"
  else
    printf 'zfs-mount-generator: (%s) invalid exec\n' \
      "${dataset}" >/dev/kmsg
  fi

  # readonly
  if [ "${p_readonly}" = on ] ; then
    opts="${opts},ro"
  elif [ "${p_readonly}" = off ] ; then
    opts="${opts},rw"
  else
    printf 'zfs-mount-generator: (%s) invalid readonly\n' \
      "${dataset}" >/dev/kmsg
  fi

  # setuid
  if [ "${p_setuid}" = on ] ; then
    opts="${opts},suid"
  elif [ "${p_setuid}" = off ] ; then
    opts="${opts},nosuid"
  else
    printf 'zfs-mount-generator: (%s) invalid setuid\n' \
      "${dataset}" >/dev/kmsg
  fi

  # nbmand
  if [ "${p_nbmand}" = on ]  ; then
    opts="${opts},mand"
  elif [ "${p_nbmand}" = off ] ; then
    opts="${opts},nomand"
  else
    printf 'zfs-mount-generator: (%s) invalid nbmand\n' \
      "${dataset}" >/dev/kmsg
  fi

  if [ -n "${p_systemd_wantedby}" ] && \
      [ "${p_systemd_wantedby}" != "-" ] ; then
    noauto="on"
    if [ "${p_systemd_wantedby}" = "none" ] ; then
      wantedby=""
    else
      wantedby="${p_systemd_wantedby}"
      before="${before} ${wantedby}"
    fi
  fi

  if [ -n "${p_systemd_requiredby}" ] && \
      [ "${p_systemd_requiredby}" != "-" ] ; then
    noauto="on"
    if [ "${p_systemd_requiredby}" = "none" ] ; then
      requiredby=""
    else
      requiredby="${p_systemd_requiredby}"
      before="${before} ${requiredby}"
    fi
  fi

  # For datasets with canmount=on, a dependency is created for
  # local-fs.target by default. To avoid regressions, this dependency
  # is reduced to "wants" rather than "requires" when nofail is not "off".
  # **THIS MAY CHANGE**
  # noauto=on disables this behavior completely.
  if [ "${noauto}" != "on" ] ; then
    if [ "${p_systemd_nofail}" = "off" ] ; then
      requiredby="local-fs.target"
      before="${before} local-fs.target"
    else
      wantedby="local-fs.target"
      if [ "${p_systemd_nofail}" != "on" ] ; then
        before="${before} local-fs.target"
      fi
    fi
  fi

  # Handle existing files:
  # 1.  We never overwrite existing files, although we may delete
  #     files if we're sure they were created by us. (see 5.)
  # 2.  We handle files differently based on canmount. Units with canmount=on
  #     always have precedence over noauto. This is enforced by the sort pipe
  #     in the loop around this function.
  #     It is important to use $p_canmount and not $noauto here, since we
  #     sort by canmount while other properties also modify $noauto, e.g.
  #     org.openzfs.systemd:wanted-by.
  # 3.  If no unit file exists for a noauto dataset, we create one.
  #     Additionally, we use $noauto_files to track the unit file names
  #     (which are the systemd-escaped mountpoints) of all (exclusively)
  #     noauto datasets that had a file created.
  # 4.  If the file to be created is found in the tracking variable,
  #     we do NOT create it.
  # 5.  If a file exists for a noauto dataset, we check whether the file
  #     name is in the variable. If it is, we have multiple noauto datasets
  #     for the same mountpoint. In such cases, we remove the file for safety.
  #     To avoid further noauto datasets creating a file for this path again,
  #     we leave the file name in the tracking variable.
  if [ -e "${dest_norm}/${mountfile}" ] ; then
    if is_known "$mountfile" "$noauto_files" ; then
      # if it's in $noauto_files, we must be noauto too. See 2.
      printf 'zfs-mount-generator: removing duplicate noauto %s\n' \
        "${mountfile}" >/dev/kmsg
      # See 5.
      rm "${dest_norm}/${mountfile}"
    else
      # don't log for canmount=noauto
      if [  "${p_canmount}" = "on" ] ; then
        printf 'zfs-mount-generator: %s already exists. Skipping.\n' \
          "${mountfile}" >/dev/kmsg
      fi
    fi
    # file exists; Skip current dataset.
    return
  else
    if is_known "${mountfile}" "${noauto_files}" ; then
      # See 4.
      return
    elif [ "${p_canmount}" = "noauto" ] ; then
      noauto_files="${mountfile} ${noauto_files}"
    fi
  fi

  # Create the .mount unit file.
  #
  # (Do not use `<<EOF`-style here-documents for this, see warning above)
  #
  echo \
"# Automatically generated by zfs-mount-generator

[Unit]
SourcePath=${cachefile}
Documentation=man:zfs-mount-generator(8)

Before=${before}
After=${after}
Wants=${wants}
${bindsto}
${requires}
${requiredmounts}

[Mount]
Where=${p_mountpoint}
What=${dataset}
Type=zfs
Options=defaults${opts},zfsutil" > "${dest_norm}/${mountfile}"

  # Finally, create the appropriate dependencies
  create_dependencies "${mountfile}" "wants" "$wantedby"
  create_dependencies "${mountfile}" "requires" "$requiredby"

}

for cachefile in "${FSLIST}/"* ; do
  # Disable glob expansion to protect against special characters when parsing.
  set -f
  # Sort cachefile's lines by canmount, "on" before "noauto"
  # and feed each line into process_line
  sort -t "$(printf '\t')" -k 3 -r "${cachefile}" | \
  ( # subshell is necessary for `sort|while read` and $noauto_files
    noauto_files=""
    while read -r fs ; do
      process_line "${fs}"
    done
  )
done
